Skip to content

fix(encryption): Refactor EncryptionWrapper, fix zero-byte size, add S3 tests#57279

Open
cuppett wants to merge 5 commits intonextcloud:masterfrom
cuppett:fix/s3-primary-storage-encryption
Open

fix(encryption): Refactor EncryptionWrapper, fix zero-byte size, add S3 tests#57279
cuppett wants to merge 5 commits intonextcloud:masterfrom
cuppett:fix/s3-primary-storage-encryption

Conversation

@cuppett
Copy link
Copy Markdown
Contributor

@cuppett cuppett commented Dec 28, 2025

Summary

This PR makes several improvements to Nextcloud's encryption infrastructure:

  1. Refactored EncryptionWrapper logic - Cleaner, more explicit conditional flow
  2. Added HomeMountPoint support - Respects encryptHomeStorage setting for home storage mounts
  3. Fixed zero-byte encrypted file size - Files with 0 bytes no longer incorrectly report as 8192 bytes
  4. Added S3 encryption test suite - Comprehensive tests for encryption with S3 primary storage
  5. Fixed test isolation - Prevents encryption state pollution between test runs

Changes

Code Fixes

lib/private/Encryption/EncryptionWrapper.php

Refactored storage wrapper logic:

  • Restructured conditionals for clarity (inverted logic for early returns)
  • Added support for encryptHomeStorage setting on HomeMountPoint mounts
  • Encryption wrapper is now always applied (when not explicitly disabled), allowing encryption to be dynamically enabled/disabled

lib/private/Files/Cache/CacheEntry.php & lib/private/Files/FileInfo.php

Fixed zero-byte file size reporting:

  • Removed > 0 check that caused 0-byte encrypted files to report as 8192 bytes (encryption header size)

Test Infrastructure

New: tests/lib/Files/ObjectStore/S3EncryptionTest.php (493 lines)

Comprehensive S3 encryption test suite covering:

  • Size consistency across database, S3, and actual content
  • Round-trip encryption/decryption for various file sizes (0 bytes to 110MB)
  • Streaming integrity, partial reads, seeking operations
  • Multipart upload support

New: tests/lib/Files/ObjectStore/S3EncryptionMigrationTest.php (270 lines)

Migration scenario tests:

  • Mixed encrypted/unencrypted content handling
  • Safe migration with encryption:encrypt-all

tests/lib/TestCase.php + 5 encryption test files

Fixed test pollution:

  • Added global encryption cleanup in tearDown()
  • Prevents MultiKeyEncryptException failures from state leakage between tests

Files Changed

File Change
lib/private/Encryption/EncryptionWrapper.php Refactored wrapper logic, added HomeMountPoint check
lib/private/Files/Cache/CacheEntry.php Fixed zero-byte size check
lib/private/Files/FileInfo.php Fixed zero-byte size check
tests/lib/TestCase.php Added encryption cleanup in tearDown()
tests/lib/Encryption/EncryptionWrapperTest.php Updated mock for new logic
tests/lib/Files/ObjectStore/S3EncryptionTest.php New - S3 encryption tests
tests/lib/Files/ObjectStore/S3EncryptionMigrationTest.php New - Migration tests
5 encryption test files in apps/ Added tearDown cleanup

Backward Compatibility

Fully backward compatible

  • No breaking changes to APIs or behavior
  • Existing encrypted files continue to work
  • encryptHomeStorage setting defaults to enabled ('1')

Testing

Run the S3 encryption tests:

OBJECT_STORE=s3 phpunit tests/lib/Files/ObjectStore/S3EncryptionTest.php

@cuppett cuppett requested a review from a team as a code owner December 28, 2025 20:15
@cuppett cuppett requested review from leftybournes, salmart-dev, sorbaugh and yemkareems and removed request for a team December 28, 2025 20:15
@cuppett cuppett marked this pull request as draft December 28, 2025 20:16
@cuppett cuppett marked this pull request as ready for review December 28, 2025 20:36
@cuppett cuppett force-pushed the fix/s3-primary-storage-encryption branch 2 times, most recently from 0a01f11 to fd39e51 Compare December 28, 2025 23:35
@cuppett cuppett marked this pull request as draft December 29, 2025 00:15
@cuppett cuppett force-pushed the fix/s3-primary-storage-encryption branch 12 times, most recently from f4f2d21 to 1a81edd Compare December 29, 2025 07:01
@CarlSchwan
Copy link
Copy Markdown
Member

Failing test seems related. e.g.

2025-12-29T07:08:54.4550616Z 1) Test\Files\ViewTest::testCopyBetweenStorageNoCross
2025-12-29T07:08:54.4551112Z BadMethodCallException: path needs to be relative to the system wide data folder and point to a user specific file
2025-12-29T07:08:54.4552424Z 
2025-12-29T07:08:54.4552642Z /home/runner/actions-runner/_work/server/server/lib/private/Encryption/Util.php:208
2025-12-29T07:08:54.4553192Z /home/runner/actions-runner/_work/server/server/lib/private/Encryption/Keys/Storage.php:418
2025-12-29T07:08:54.4553758Z /home/runner/actions-runner/_work/server/server/lib/private/Encryption/Keys/Storage.php:368
2025-12-29T07:08:54.4554377Z /home/runner/actions-runner/_work/server/server/lib/private/Files/Storage/Wrapper/Encryption.php:877
2025-12-29T07:08:54.4555029Z /home/runner/actions-runner/_work/server/server/lib/private/Files/Storage/Wrapper/Encryption.php:673
2025-12-29T07:08:54.4555559Z /home/runner/actions-runner/_work/server/server/lib/private/Files/Storage/Wrapper/Encryption.php:582
2025-12-29T07:08:54.4556062Z /home/runner/actions-runner/_work/server/server/lib/private/Files/Storage/Wrapper/Wrapper.php:289
2025-12-29T07:08:54.4556501Z /home/runner/actions-runner/_work/server/server/lib/private/Files/View.php:968
2025-12-29T07:08:54.4556878Z /home/runner/actions-runner/_work/server/server/tests/lib/Files/ViewTest.php:475
2025-12-29T07:08:54.4557827Z /home/runner/actions-runner/_work/server/server/tests/lib/Files/ViewTest.php:454

@icewind1991
Copy link
Copy Markdown
Member

Impact: When S3 is configured as primary object storage (via objectstore in config.php), it mounts at /, so encryption was NEVER applied, regardless of settings.

This is not true, the home storage is mounted at /$user same as with local storage.

@cuppett cuppett force-pushed the fix/s3-primary-storage-encryption branch from fcadd6f to c0c3ae6 Compare December 29, 2025 15:34
@cuppett cuppett changed the title fix(encryption): Enable encryption for primary object storage (S3, Swift) fix(encryption): Refactor EncryptionWrapper, fix zero-byte size, add S3 tests Dec 29, 2025
@cuppett cuppett force-pushed the fix/s3-primary-storage-encryption branch 2 times, most recently from 30903a9 to 4dc1def Compare December 29, 2025 22:32
@cuppett cuppett marked this pull request as ready for review December 29, 2025 22:35
@cuppett cuppett force-pushed the fix/s3-primary-storage-encryption branch 2 times, most recently from f723818 to 1e1aeaa Compare April 23, 2026 00:22
Comment thread core/Command/Encryption/DecryptAll.php Outdated
Comment thread core/Command/Encryption/Disable.php Outdated
Comment thread core/Command/Encryption/Enable.php Outdated
Comment thread lib/private/Encryption/EncryptionWrapper.php Outdated
Comment thread lib/private/Encryption/EncryptionWrapper.php
@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 25, 2026

Working through propagating getBoolVal everywhere.

@miaulalala
Copy link
Copy Markdown
Contributor

miaulalala commented Apr 25, 2026

Your tests fail bc the IAppConfig methods are called before the Nextcloud install is finished. The old code handled that gracefully, but the current code does not. Please change your code to wrap the IAppConfig calls in Manager::isEnabled() and EncryptionWrapper::wrapStorage() with a try/catch for \OCP\DB\Exception (or \Throwable) that returns the safe default, similar to how the old IConfig path behaved.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/Command/Encryption/Enable.php Outdated
Comment thread core/Command/Encryption/Disable.php Outdated
Comment thread lib/private/Encryption/EncryptionWrapper.php Outdated
Comment thread tests/lib/Files/ObjectStore/S3EncryptionTest.php Outdated
Comment thread tests/lib/Files/ObjectStore/S3EncryptionMigrationTest.php
Comment thread apps/encryption/tests/Settings/AdminTest.php
@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 25, 2026

@miaulalala Okay, I'm back!

Did the things to convert to a boolean throughout for that app config setting.

@miaulalala
Copy link
Copy Markdown
Contributor

@miaulalala Okay, I'm back!

Did the things to convert to a boolean throughout for that app config setting.

PR is looking good, tests pass. @artonge or @CarlSchwan can either of you review?

Copy link
Copy Markdown
Collaborator

@artonge artonge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the submission @cuppett.

Given the sensitive nature of the code, would it be possible for you to submit the change in several easier to review pull requests?

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 28, 2026

Thanks for the submission @cuppett.

Given the sensitive nature of the code, would it be possible for you to submit the change in several easier to review pull requests?

@artonge A bulk of the changes are in testing. The logic changed by +222 / -65. The rest is testing. Teasing that apart will be challenging logically. I think I could rearrange individual commits depending on how you want to review it.

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 28, 2026

Pushed through rebase to newest master

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

Rebase only.

@artonge
Copy link
Copy Markdown
Collaborator

artonge commented Apr 29, 2026

Teasing that apart will be challenging logically.

Your summary in your first comment suggests that you identified key changes, that would probably be a good way to split it.

I think I could rearrange individual commits depending on how you want to review it.

Indeed, 18 commits seem like a lot compared to your summary.

Maybe to give a bit of context, we are receiving an increasing number of large PRs, which takes a lot of time to review, splitting the PR into manageable chunks would drastically help us to review it. Some can probably be quickly reviewed and merged, other might require some discussion.

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

Teasing that apart will be challenging logically.

Your summary in your first comment suggests that you identified key changes, that would probably be a good way to split it.

I think I could rearrange individual commits depending on how you want to review it.

Indeed, 18 commits seem like a lot compared to your summary.

Maybe to give a bit of context, we are receiving an increasing number of large PRs, which takes a lot of time to review, splitting the PR into manageable chunks would drastically help us to review it. Some can probably be quickly reviewed and merged, other might require some discussion.

Let me squash them into the 5 passes. Waiting 5 months to get these first couple reviews (or peeks) isn't something I want to repeat. :)

cuppett and others added 5 commits April 29, 2026 12:01
Rewrite conditional flow to use early-return guards: skip IDisableEncryptionStorage,
skip the root mount, respect encryptHomeStorage for HomeMountPoints. Uses IAppConfig
for the encryptHomeStorage setting with a legacy string fallback for the upgrade window.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stephen Cuppett <steve@cuppett.com>
Files with 0 bytes no longer incorrectly report as 8192 bytes. Widens unencryptedSize
to ?int, fixes verifyUnencryptedSize to compare against header size instead of 0,
and corrects Scanner to populate unencrypted_size on initial upload.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stephen Cuppett <steve@cuppett.com>
…rage

Comprehensive tests covering encryption with S3 as primary storage backend, including
upload/download, multipart, migration detection, and key validation. EncryptionTrait
updated to use IAppConfig and validate share/master keys on setup.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stephen Cuppett <steve@cuppett.com>
Add global encryption teardown to TestCase base class so encryption state
does not leak between test suites regardless of which tests ran earlier.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stephen Cuppett <steve@cuppett.com>
… with repair step

Switch all encryption config reads/writes from deprecated string-typed IConfig to
bool-typed IAppConfig (getValueBool/setValueBool). Adds RetypeEncryptionConfigKeys
repair step to retype existing string values to bool on upgrade. Includes lazy
IAppConfig resolution in Manager and AppConfigTypeConflictException fallbacks
throughout for safety during the upgrade window.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stephen Cuppett <steve@cuppett.com>
@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

@artonge I got it squashed to 5 discrete commits. Additional commits were mostly fix/patches for tests. Combined no 1 and 2 from summary, but 5th commit is the pervasive IAppConfig bool usage throughout and fixups. Waiting on tests now. Thanks!

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

Related Issue Analysis

Reviewed open issues related to S3 encryption and zero-byte file problems. This PR addresses or is relevant to the following:


Fully Fixed

#58778conflict between new type (mixed) and old type (boolean) on re-encrypt

After occ encryption:decrypt-all followed by occ encryption:encrypt-all, users hit conflict between new type (mixed) and old type (boolean) in AppConfig.php and must manually delete the core/encryption_enabled row from oc_appconfig to recover.

Root cause: Disable.php wrote encryption_enabled as string 'no', but IAppConfig internally stored it as bool 0. Enable.php then tried to write string 'yes', triggering a type conflict.

How this PR fixes it: All Enable/Disable/DecryptAll commands now use setValueBool()/getValueBool() instead of untyped setAppValue(). The RetypeEncryptionConfigKeys repair step converts any existing string-typed values to bool on upgrade, eliminating type conflicts entirely.


Partially Fixed / Directly Related

#41992 — Server-side encryption does not encrypt files with S3 primary storage

Files upload to S3 but are NOT encrypted (only very small text files encrypt, larger files remain unencrypted).

What this PR fixes: The EncryptionWrapper conditional flow is refactored into clear early-return guards. The encryptHomeStorage check is moved from deep inside shouldEncrypt() during per-file fopen() to wrapper-application time. When encryption IS enabled for home storage, the wrapper is now consistently applied with correct parameters. This makes wrapper application deterministic, which likely resolves cases where the wrapper was inconsistently applied.

What remains: If the encryption module's shouldEncrypt() has separate bugs for multipart/chunked uploads on S3, those are outside this PR's scope. The reported symptom (small files encrypt, large files don't) could suggest a multipart-specific path issue deeper than wrapper application.


#59635 — Config for files_external mount option "encryption" is not respected

External S3 mount with "Activate Encryption" = false still encrypts files when SSE is globally enabled.

What this PR fixes: The EncryptionWrapper now has a clear guard-chain pattern with early returns. The IDisableEncryptionStorage check runs early and cleanly, and HomeMountPoint + encryptHomeStorage is explicitly handled.

What remains: The per-mount "Activate Encryption" toggle for files_external needs to either implement IDisableEncryptionStorage or be checked in the wrapper's guard chain, which this PR does not add. The guard-chain pattern introduced here makes adding that check straightforward in a follow-up.


#58239 — Regenerating metadata with SSE serves files encrypted (not decrypted)

After occ files:scan --all --generate-metadata, files are served encrypted instead of being decrypted.

What this PR fixes: The Scanner now correctly preserves unencrypted_size for encrypted files during rescans. Previously, the scanner stripped unencrypted_size from scan data unconditionally, potentially causing the encryption wrapper to mishandle files on subsequent reads.

What remains: If the metadata scan resets the encrypted flag itself or corrupts encryption key references, that's a separate code path. Testing with the specific reproduction steps from the issue would confirm whether this PR resolves it.

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

Fixes #58778

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

S3 Encryption Verification — Real AWS S3 Bucket

Ran the full S3 encryption test suite against a real AWS S3 bucket (us-east-1) to verify the fix for #41992 ("Server side encryption does not encrypt files with S3 primary storage").

Setup

  • Fresh Nextcloud install with SQLite + S3 primary storage
  • Server-side encryption enabled with master key
  • PHPUnit test suite exercising the actual encryption/decryption pipeline through real S3

Results

PHPUnit 11.5.50 — Tests: 20, Assertions: 106, Skipped: 1 (110MB multipart gated)
OK

110MB multipart (run separately): Tests: 1, Assertions: 6
OK
Test Sizes Covered What It Verifies
testSizeConsistencyAcrossSources 0B, 256B, 1KB, 1MB, 5MB, 16MB S3 object is larger than original (encryption overhead proves encryption happened), DB unencrypted_size matches original, content round-trips correctly
testEncryptedFileRoundTrip 0B, 256B, 1KB, 1MB, 5MB, 16MB Write encrypted → read back → matches original
testEncryptedFileIntegrity 0B, 256B, 1KB, 1MB, 5MB, 16MB Streaming hash verification of content integrity
testEncryptedMultipartUpload 110MB Multipart upload is encrypted, DB has correct unencrypted size, streaming hash verification of content
testEncryptedFileSizeTracking 1KB, 10KB, 100KB Size tracking consistency across multiple encrypted files
testEncryptedFilePartialRead 100KB Partial read returns correct subset of decrypted content
testEncryptedFileSeek 50KB Seek operations work correctly on encrypted streams
testEncryptedFileMimeType text MIME type preserved through encryption

Addressing #41992

The core symptom in #41992 was: "Files will sync and upload to the S3 provider but are not encrypted, unless they are very small." — specifically 8MB+ files remained unencrypted.

The testSizeConsistencyAcrossSources test explicitly asserts that the S3 object size > original file size for every test size (5MB, 16MB), proving encryption overhead is present. The 110MB multipart test confirms the same for chunked uploads that exceed the ~100MB multipart threshold.

Three assertions prove encryption is working end-to-end:

  1. S3 object is larger than the original (encryption header + padding overhead)
  2. DB unencrypted_size equals original file size (correct metadata tracking)
  3. Downloaded content matches original (decryption pipeline works)

This branch fully addresses #41992 for all file sizes including multipart uploads.

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

Analysis: #59635 — Per-mount encryption toggle not respected

Result: Not fixed by this branch, but root cause identified

The per-mount "Activate Encryption" toggle failing is a separate bug in apps/files_external/lib/Service/DBConfigService.php, unrelated to this PR's changes.


Root cause

DBConfigService::setOption() at line 387 has a string $value type hint:

public function setOption(int $mountId, string $key, string $value): void {
    // ...
    ->setValue('value', $builder->createNamedParameter(json_encode($value), ...))

When called with false (boolean) from StoragesService::updateStorage():

foreach ($changedOptions as $key => $value) {
    $this->dbConfig->setOption($id, $key, $value);  // $value is bool false
}

PHP silently coerces false → "" due to the string type hint. Then json_encode("") = '""' is stored in oc_external_options. On read, json_decode('""') = "" (empty string). The shouldEncrypt() check at Encryption.php:916 is === false, which never matches an empty string — so encryption proceeds.

Traced end-to-end:

Step Value Type
UI sets encrypt = false false bool
setOption(string $value) receives it "" string (coerced by PHP)
json_encode("") stored in DB "" string literal
json_decode('""') on read "" string
$mountPointConfig === false check false never matches

What would fix it: Change setOption's parameter type from string to mixed, so json_encode(false) stores false (the JSON boolean) instead of "".


Verification

Confirmed against a live Nextcloud 34 install with S3 as primary storage:

sqlite> SELECT key, value FROM oc_external_options WHERE mount_id=1;
enable_sharing|""
encrypt|""

After occ files_external:option 1 encrypt false, the DB stores "" instead of false. The per-mount toggle is silently ignored at runtime.


Status

This PR's EncryptionWrapper refactoring makes the guard chain cleaner and extensible, but the per-mount encrypt option was always handled downstream in shouldEncrypt() (unchanged here). The fix belongs in DBConfigService::setOption() and is worth a separate issue/PR targeting files_external.

@artonge
Copy link
Copy Markdown
Collaborator

artonge commented Apr 29, 2026

@cuppett please, cleanup that noise, this is the opposite of what would be respectful to us. I assume you did not even took the time to double-check those outputs, why would we? I am asking you again to make it easy for us to review your changes, split your PR, give us consise summaries that go straight to the point, not AI generated rivers of text.

@cuppett
Copy link
Copy Markdown
Contributor Author

cuppett commented Apr 29, 2026

@cuppett please, cleanup that noise, this is the opposite of what would be respectful to us. I assume you did not even took the time to double-check those outputs, why would we? I am asking you again to make it easy for us to review your changes, split your PR, give us consise summaries that go straight to the point, not AI generated rivers of text.

Okay, I'll sequence them and pop the refs in here for lineage. Easy enough now with the squashed ones to land in a good order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

7 participants